image_scorer.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. # image_quality_scorer.py
  2. import logging
  3. from typing import Dict, List, Tuple
  4. import numpy as np
  5. from PIL import Image
  6. import cv2
  7. from sklearn.cluster import KMeans
  8. import webcolors
  9. import io
  10. logger = logging.getLogger(__name__)
  11. class ImageQualityScorer:
  12. """
  13. Image Quality Scorer for Product Images
  14. Evaluates: resolution, blur, background, size, format
  15. """
  16. def __init__(self, use_ai: bool = True):
  17. self.use_ai = use_ai
  18. self.ai_service = None
  19. # Initialize AI service if available
  20. if use_ai:
  21. try:
  22. from .gemini_service import GeminiAttributeService
  23. self.ai_service = GeminiAttributeService()
  24. except Exception as e:
  25. logger.warning(f"Gemini service not available: {e}")
  26. self.use_ai = False
  27. # Image scoring weights
  28. self.image_weights = {
  29. 'resolution': 0.25, # 25%
  30. 'clarity': 0.25, # 25%
  31. 'background': 0.20, # 20%
  32. 'size': 0.15, # 15%
  33. 'format': 0.15 # 15%
  34. }
  35. # Standards
  36. self.min_width = 500
  37. self.recommended_width = 1000
  38. self.min_height = 500
  39. self.recommended_height = 1000
  40. self.min_dpi = 72
  41. self.recommended_dpi = 150
  42. self.min_blur_variance = 100
  43. self.recommended_blur_variance = 500
  44. self.recommended_formats = ['JPEG', 'PNG', 'WEBP']
  45. self.max_file_size_mb = 5
  46. def score_image(self, product: Dict, image_data: bytes = None, image_path: str = None) -> Dict:
  47. """
  48. Main scoring function for product images
  49. Args:
  50. product: Product dictionary with metadata
  51. image_data: Raw image bytes (optional)
  52. image_path: Path to image file (optional)
  53. Returns:
  54. Dictionary with scores, issues, and suggestions
  55. """
  56. try:
  57. # Load image
  58. if image_data:
  59. image = Image.open(io.BytesIO(image_data)).convert("RGB")
  60. elif image_path:
  61. image = Image.open(image_path).convert("RGB") # <-- FIXED: removed hardcoded path
  62. else:
  63. return {
  64. 'image_score': 0.0,
  65. 'breakdown': {},
  66. 'issues': ['No image provided'],
  67. 'suggestions': ['Upload a product image'],
  68. 'image_metadata': {}
  69. }
  70. image_np = np.array(image)
  71. # Extract metadata
  72. metadata = self._extract_metadata(image, image_data or image_path)
  73. # Score components
  74. scores = {}
  75. issues = []
  76. suggestions = []
  77. # 1. Resolution (25%)
  78. res_score, res_issues, res_suggestions = self._check_resolution(image, metadata)
  79. scores['resolution'] = res_score
  80. issues.extend(res_issues)
  81. suggestions.extend(res_suggestions)
  82. # 2. Clarity/Blur (25%)
  83. clarity_score, clarity_issues, clarity_suggestions = self._check_clarity(image_np)
  84. scores['clarity'] = clarity_score
  85. issues.extend(clarity_issues)
  86. suggestions.extend(clarity_suggestions)
  87. # 3. Background (20%)
  88. bg_score, bg_issues, bg_suggestions, bg_info = self._check_background(image_np)
  89. scores['background'] = bg_score
  90. issues.extend(bg_issues)
  91. suggestions.extend(bg_suggestions)
  92. # 4. Size (15%)
  93. size_score, size_issues, size_suggestions = self._check_size(image, metadata)
  94. scores['size'] = size_score
  95. issues.extend(size_issues)
  96. suggestions.extend(size_suggestions)
  97. # 5. Format (15%)
  98. format_score, format_issues, format_suggestions = self._check_format(image, metadata)
  99. scores['format'] = format_score
  100. issues.extend(format_issues)
  101. suggestions.extend(format_suggestions)
  102. # Calculate final score
  103. final_score = sum(scores[key] * self.image_weights[key] for key in scores)
  104. return {
  105. 'image_score': round(final_score, 2),
  106. 'breakdown': scores,
  107. 'issues': issues,
  108. 'suggestions': suggestions,
  109. 'image_metadata': {
  110. **metadata,
  111. **bg_info
  112. },
  113. 'ai_improvements': self._get_ai_improvements(product, scores, issues) if self.use_ai else None
  114. }
  115. except Exception as e:
  116. logger.error(f"Image scoring error: {e}", exc_info=True)
  117. return {
  118. 'image_score': 0.0,
  119. 'breakdown': {},
  120. 'issues': [f"Image processing failed: {str(e)}"],
  121. 'suggestions': ['Ensure image is valid and accessible'],
  122. 'image_metadata': {}
  123. }
  124. def _extract_metadata(self, image: Image.Image, source) -> Dict:
  125. """Extract image metadata"""
  126. width, height = image.size
  127. # Get DPI
  128. dpi = image.info.get('dpi', (None, None))
  129. if not dpi or dpi == (None, None):
  130. # Try EXIF
  131. try:
  132. import piexif
  133. exif_data = piexif.load(image.info.get('exif', b''))
  134. x_res = exif_data['0th'].get(piexif.ImageIFD.XResolution, None)
  135. y_res = exif_data['0th'].get(piexif.ImageIFD.YResolution, None)
  136. if x_res and y_res:
  137. dpi = (int(x_res[0] / x_res[1]), int(y_res[0] / y_res[1]))
  138. else:
  139. dpi = (None, None)
  140. except Exception:
  141. dpi = (None, None)
  142. # Get file size
  143. file_size_mb = None
  144. if isinstance(source, bytes):
  145. file_size_mb = len(source) / (1024 * 1024)
  146. elif isinstance(source, str):
  147. import os
  148. if os.path.exists(source):
  149. file_size_mb = os.path.getsize(source) / (1024 * 1024)
  150. return {
  151. 'width': width,
  152. 'height': height,
  153. 'dpi': dpi,
  154. 'format': image.format,
  155. 'mode': image.mode,
  156. 'file_size_mb': round(file_size_mb, 2) if file_size_mb else None
  157. }
  158. def _check_resolution(self, image: Image.Image, metadata: Dict) -> Tuple[float, List[str], List[str]]:
  159. """Check image resolution (DPI)"""
  160. issues = []
  161. suggestions = []
  162. dpi = metadata.get('dpi', (None, None))
  163. if not dpi or dpi == (None, None) or dpi[0] is None:
  164. suggestions.append("DPI information not available in image, ensure high-quality source")
  165. score = 70.0
  166. else:
  167. avg_dpi = (dpi[0] + dpi[1]) / 2 if dpi[1] else dpi[0]
  168. if avg_dpi < self.min_dpi:
  169. issues.append(f"Image: Low resolution ({avg_dpi} DPI, minimum {self.min_dpi})")
  170. suggestions.append(f"Use images with at least {self.recommended_dpi} DPI")
  171. score = (avg_dpi / self.min_dpi) * 50
  172. elif avg_dpi < self.recommended_dpi:
  173. suggestions.append(f"Resolution acceptable but could be better (current: {avg_dpi} DPI)")
  174. score = 50 + ((avg_dpi - self.min_dpi) / (self.recommended_dpi - self.min_dpi)) * 50
  175. else:
  176. score = 100.0
  177. return score, issues, suggestions
  178. def _check_clarity(self, image_np: np.ndarray) -> Tuple[float, List[str], List[str]]:
  179. """Check image clarity using Laplacian variance (blur detection)"""
  180. issues = []
  181. suggestions = []
  182. try:
  183. gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY)
  184. blur_variance = cv2.Laplacian(gray, cv2.CV_64F).var()
  185. if blur_variance < self.min_blur_variance:
  186. issues.append(f"Image: Blurry/low clarity (variance: {blur_variance:.2f})")
  187. suggestions.append("Use sharp, well-focused images (variance should be > 500)")
  188. score = (blur_variance / self.min_blur_variance) * 50
  189. elif blur_variance < self.recommended_blur_variance:
  190. suggestions.append(f"Image clarity acceptable but could be sharper (variance: {blur_variance:.2f})")
  191. score = 50 + ((blur_variance - self.min_blur_variance) / (self.recommended_blur_variance - self.min_blur_variance)) * 50
  192. else:
  193. score = 100.0
  194. except Exception as e:
  195. logger.warning(f"Blur detection error: {e}")
  196. score = 70.0
  197. suggestions.append("Unable to assess image clarity")
  198. return score, issues, suggestions
  199. def _check_background(self, image_np: np.ndarray) -> Tuple[float, List[str], List[str], Dict]:
  200. """Check background color and coverage"""
  201. issues = []
  202. suggestions = []
  203. bg_info = {}
  204. try:
  205. pixels = image_np.reshape(-1, 3)
  206. kmeans = KMeans(n_clusters=3, random_state=0, n_init=10).fit(pixels)
  207. # Get dominant color
  208. dominant_idx = np.argmax(np.bincount(kmeans.labels_))
  209. dominant_color = tuple(kmeans.cluster_centers_[dominant_idx].astype(int))
  210. # Color name and hex
  211. color_name = self._closest_color_name(dominant_color)
  212. hex_code = webcolors.rgb_to_hex(dominant_color)
  213. # Background coverage
  214. bg_pixels = np.sum(kmeans.labels_ == dominant_idx)
  215. total_pixels = len(kmeans.labels_)
  216. background_coverage = 100 * bg_pixels / total_pixels
  217. bg_info = {
  218. 'dominant_color_rgb': dominant_color,
  219. 'dominant_color_hex': hex_code,
  220. 'dominant_color_name': color_name,
  221. 'background_coverage': round(background_coverage, 2),
  222. 'blur_variance': cv2.Laplacian(cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY), cv2.CV_64F).var()
  223. }
  224. # Score based on white/light background preference
  225. score_components = []
  226. # 1. Check if background is white/light (preferred for e-commerce)
  227. if color_name.lower() in ['white', 'whitesmoke', 'snow', 'ivory', 'linen']:
  228. score_components.append(100.0)
  229. elif sum(dominant_color) / 3 > 200: # Light color
  230. score_components.append(85.0)
  231. elif color_name.lower() in ['lightgray', 'lightgrey', 'gainsboro']:
  232. score_components.append(75.0)
  233. suggestions.append("Consider using pure white background for better product visibility")
  234. else:
  235. issues.append(f"Image: Non-white background ({color_name})")
  236. suggestions.append("Use white or light neutral background for e-commerce standards")
  237. score_components.append(50.0)
  238. # 2. Check coverage (background should be dominant)
  239. if background_coverage > 60:
  240. score_components.append(100.0)
  241. elif background_coverage > 40:
  242. score_components.append(80.0)
  243. else:
  244. suggestions.append(f"Background coverage low ({background_coverage:.1f}%), product may be too small")
  245. score_components.append(60.0)
  246. final_score = np.mean(score_components)
  247. except Exception as e:
  248. logger.warning(f"Background analysis error: {e}")
  249. final_score = 70.0
  250. suggestions.append("Unable to analyze background")
  251. return final_score, issues, suggestions, bg_info
  252. def _check_size(self, image: Image.Image, metadata: Dict) -> Tuple[float, List[str], List[str]]:
  253. """Check image dimensions"""
  254. issues = []
  255. suggestions = []
  256. width = metadata['width']
  257. height = metadata['height']
  258. score_components = []
  259. # Width check
  260. if width < self.min_width:
  261. issues.append(f"Image: Width too small ({width}px, minimum {self.min_width}px)")
  262. suggestions.append(f"Use images at least {self.recommended_width}x{self.recommended_height}px")
  263. score_components.append((width / self.min_width) * 50)
  264. elif width < self.recommended_width:
  265. suggestions.append(f"Image width acceptable but could be larger (current: {width}px)")
  266. score_components.append(50 + ((width - self.min_width) / (self.recommended_width - self.min_width)) * 50)
  267. else:
  268. score_components.append(100.0)
  269. # Height check
  270. if height < self.min_height:
  271. issues.append(f"Image: Height too small ({height}px, minimum {self.min_height}px)")
  272. score_components.append((height / self.min_height) * 50)
  273. elif height < self.recommended_height:
  274. score_components.append(50 + ((height - self.min_height) / (self.recommended_height - self.min_height)) * 50)
  275. else:
  276. score_components.append(100.0)
  277. # Aspect ratio check (should be roughly square or standard format)
  278. aspect_ratio = width / height
  279. if 0.75 <= aspect_ratio <= 1.33: # 4:3 to 3:4 range
  280. score_components.append(100.0)
  281. else:
  282. suggestions.append(f"Image aspect ratio unusual ({aspect_ratio:.2f}), consider standard format")
  283. score_components.append(80.0)
  284. final_score = np.mean(score_components)
  285. return final_score, issues, suggestions
  286. def _check_format(self, image: Image.Image, metadata: Dict) -> Tuple[float, List[str], List[str]]:
  287. """Check image format and file size"""
  288. issues = []
  289. suggestions = []
  290. score_components = []
  291. # Format check
  292. img_format = metadata.get('format', '').upper()
  293. if img_format in self.recommended_formats:
  294. score_components.append(100.0)
  295. elif img_format in ['JPG']: # JPG vs JPEG
  296. score_components.append(100.0)
  297. elif img_format in ['GIF', 'BMP', 'TIFF']:
  298. suggestions.append(f"Image format {img_format} acceptable but consider JPEG/PNG/WEBP")
  299. score_components.append(75.0)
  300. else:
  301. issues.append(f"Image: Uncommon format ({img_format})")
  302. suggestions.append("Use standard formats: JPEG, PNG, or WEBP")
  303. score_components.append(50.0)
  304. # File size check
  305. file_size_mb = metadata.get('file_size_mb')
  306. if file_size_mb:
  307. if file_size_mb <= self.max_file_size_mb:
  308. score_components.append(100.0)
  309. elif file_size_mb <= self.max_file_size_mb * 1.5:
  310. suggestions.append(f"Image file size large ({file_size_mb:.2f}MB), consider optimization")
  311. score_components.append(80.0)
  312. else:
  313. issues.append(f"Image: File size too large ({file_size_mb:.2f}MB, max {self.max_file_size_mb}MB)")
  314. suggestions.append("Compress image to reduce file size")
  315. score_components.append(50.0)
  316. else:
  317. score_components.append(85.0) # Default if size unknown
  318. final_score = np.mean(score_components)
  319. return final_score, issues, suggestions
  320. def _closest_color_name(self, rgb_color: tuple) -> str:
  321. """Convert RGB to closest CSS3 color name"""
  322. min_distance = float('inf')
  323. closest_name = 'unknown'
  324. try:
  325. for name in webcolors.names():
  326. r, g, b = webcolors.name_to_rgb(name)
  327. distance = (r - rgb_color[0])**2 + (g - rgb_color[1])**2 + (b - rgb_color[2])**2
  328. if distance < min_distance:
  329. min_distance = distance
  330. closest_name = name
  331. except Exception as e:
  332. logger.warning(f"Color name detection error: {e}")
  333. return closest_name
  334. def _get_ai_improvements(self, product: Dict, scores: Dict, issues: List[str]) -> Dict:
  335. """Use Gemini AI to suggest image improvements"""
  336. if not self.use_ai or not self.ai_service:
  337. return None
  338. try:
  339. if not issues:
  340. return {"note": "No improvements needed"}
  341. prompt = f"""Analyze this product image quality report and suggest improvements.
  342. PRODUCT: {product.get('title', 'Unknown')}
  343. CATEGORY: {product.get('category', 'Unknown')}
  344. SCORES:
  345. {chr(10).join(f"• {k}: {v:.1f}/100" for k, v in scores.items())}
  346. ISSUES:
  347. {chr(10).join(f"• {issue}" for issue in issues[:10])}
  348. Return ONLY this JSON:
  349. {{
  350. "priority_fixes": ["fix1", "fix2", "fix3"],
  351. "recommended_specs": {{"width": 1200, "height": 1200, "format": "JPEG", "background": "white"}},
  352. "improvement_notes": ["note1", "note2"],
  353. "confidence": "high/medium/low"
  354. }}"""
  355. response = self.ai_service._call_gemini_api(prompt, max_tokens=1024)
  356. if response and response.candidates:
  357. return self.ai_service._parse_response(response.text)
  358. return {"error": "No AI response"}
  359. except Exception as e:
  360. logger.error(f"AI improvement error: {e}")
  361. return {"error": str(e)}